/******************************************************************************
 * Copyright (c) 2016-2018 TypeFox and others.
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Public License v. 2.0 which is available at
 * http://www.eclipse.org/legal/epl-2.0,
 * or the Eclipse Distribution License v. 1.0 which is available at
 * http://www.eclipse.org/org/documents/edl-v10.php.
 *
 * SPDX-License-Identifier: EPL-2.0 OR BSD-3-Clause
 ******************************************************************************/
package org.eclipse.lsp4j.jsonrpc.json.adapters;

import java.io.EOFException;
import java.io.IOException;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;

import org.eclipse.lsp4j.jsonrpc.MessageIssueException;
import org.eclipse.lsp4j.jsonrpc.json.JsonRpcMethod;
import org.eclipse.lsp4j.jsonrpc.json.MessageConstants;
import org.eclipse.lsp4j.jsonrpc.json.MessageJsonHandler;
import org.eclipse.lsp4j.jsonrpc.json.MethodProvider;
import org.eclipse.lsp4j.jsonrpc.messages.Either;
import org.eclipse.lsp4j.jsonrpc.messages.Message;
import org.eclipse.lsp4j.jsonrpc.messages.MessageIssue;
import org.eclipse.lsp4j.jsonrpc.messages.NotificationMessage;
import org.eclipse.lsp4j.jsonrpc.messages.RequestMessage;
import org.eclipse.lsp4j.jsonrpc.messages.ResponseError;
import org.eclipse.lsp4j.jsonrpc.messages.ResponseErrorCode;
import org.eclipse.lsp4j.jsonrpc.messages.ResponseMessage;

import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonIOException;
import com.google.gson.JsonNull;
import com.google.gson.JsonParseException;
import com.google.gson.JsonParser;
import com.google.gson.JsonSyntaxException;
import com.google.gson.TypeAdapter;
import com.google.gson.TypeAdapterFactory;
import com.google.gson.reflect.TypeToken;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonToken;
import com.google.gson.stream.JsonWriter;
import com.google.gson.stream.MalformedJsonException;

/**
 * The type adapter for messages dispatches between the different message types: {@link RequestMessage},
 * {@link ResponseMessage}, and {@link NotificationMessage}.
 */
public class MessageTypeAdapter extends TypeAdapter<Message> {

	public static class Factory implements TypeAdapterFactory {

		private final MessageJsonHandler handler;

		public Factory(MessageJsonHandler handler) {
			this.handler = handler;
		}

		@Override
		@SuppressWarnings("unchecked")
		public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> typeToken) {
			if (!Message.class.isAssignableFrom(typeToken.getRawType()))
				return null;
			return (TypeAdapter<T>) new MessageTypeAdapter(handler, gson);
		}

	}

	private static Type[] EMPTY_TYPE_ARRAY = {};

	private final MessageJsonHandler handler;
	private final Gson gson;

	public MessageTypeAdapter(MessageJsonHandler handler, Gson gson) {
		this.handler = handler;
		this.gson = gson;
	}

	@Override
	public Message read(JsonReader in) throws IOException, JsonIOException, JsonSyntaxException {
		if (in.peek() == JsonToken.NULL) {
			in.nextNull();
			return null;
		}

		in.beginObject();
		String jsonrpc = null, method = null;
		Either<String, Number> id = null;
		Object rawParams = null;
		Object rawResult = null;
		ResponseError responseError = null;
		try {

			while (in.hasNext()) {
				String name = in.nextName();
				switch (name) {
				case "jsonrpc": {
					jsonrpc = in.nextString();
					break;
				}
				case "id": {
					if (in.peek() == JsonToken.NUMBER)
						id = Either.forRight(in.nextInt());
					else
						id = Either.forLeft(in.nextString());
					break;
				}
				case "method": {
					method = in.nextString();
					break;
				}
				case "params": {
					rawParams = parseParams(in, method);
					break;
				}
				case "result": {
					rawResult = parseResult(in, id != null ? id.get().toString() : null);
					break;
				}
				case "error": {
					responseError = gson.fromJson(in, ResponseError.class);
					break;
				}
				default:
					in.skipValue();
				}
			}
			Object params = parseParams(rawParams, method);
			Object result = parseResult(rawResult, id != null ? id.get().toString() : null);

			in.endObject();
			return createMessage(jsonrpc, id, method, params, result, responseError);

		} catch (JsonSyntaxException | MalformedJsonException | EOFException exception) {
			if (id != null || method != null) {
				// Create a message and bundle it to an exception with an issue that wraps the original exception
				Message message = createMessage(jsonrpc, id, method, rawParams, rawResult, responseError);
				final var issue = new MessageIssue("Message could not be parsed.", ResponseErrorCode.ParseError.getValue(), exception);
				throw new MessageIssueException(message, issue);
			}
			throw exception;
		}
	}

	/**
	 * Convert the json input into the result object corresponding to the call made
	 * by id.
	 *
	 * If the id is not known until after parsing, call
	 * {@link #parseResult(Object, String)} on the return value of this call for a
	 * second chance conversion.
	 *
	 * @param in
	 *            json input to read from
	 * @param id
	 *            id of request message this is in response to
	 * @return correctly typed object if the correct expected type can be
	 *         determined, or a JsonElement representing the result
	 */
	protected Object parseResult(JsonReader in, String id) throws JsonIOException, JsonSyntaxException {
		Type type = null;
		MethodProvider methodProvider = handler.getMethodProvider();
		if (methodProvider != null && id != null) {
			String resolvedMethod = methodProvider.resolveMethod(id);
			if (resolvedMethod != null) {
				JsonRpcMethod jsonRpcMethod = handler.getJsonRpcMethod(resolvedMethod);
				if (jsonRpcMethod != null) {
					type = jsonRpcMethod.getReturnType();
					if (jsonRpcMethod.getReturnTypeAdapterFactory() != null) {
						TypeAdapter<?> typeAdapter = jsonRpcMethod.getReturnTypeAdapterFactory().create(gson, TypeToken.get(type));
						try {
							if (typeAdapter != null)
								return typeAdapter.read(in);
						} catch (IOException exception) {
							throw new JsonIOException(exception);
						}
					}
				}
			}
		}
		return fromJson(in, type);
	}

	/**
	 * Convert the JsonElement into the result object corresponding to the call made
	 * by id. If the result is already converted, does nothing.
	 *
	 * @param result
	 *            json element to read from
	 * @param id
	 *            id of request message this is in response to
	 * @return correctly typed object if the correct expected type can be
	 *         determined, or result unmodified if no conversion can be done.
	 */
	protected Object parseResult(Object result, String id) throws JsonSyntaxException {
		if (result instanceof JsonElement) {
			// Type of result could not be resolved - try again with the parsed JSON tree
			Type type = null;
			MethodProvider methodProvider = handler.getMethodProvider();
			if (methodProvider != null) {
				String resolvedMethod = methodProvider.resolveMethod(id);
				if (resolvedMethod != null) {
					JsonRpcMethod jsonRpcMethod = handler.getJsonRpcMethod(resolvedMethod);
					if (jsonRpcMethod != null) {
						type = jsonRpcMethod.getReturnType();
						if (jsonRpcMethod.getReturnTypeAdapterFactory() != null) {
							TypeAdapter<?> typeAdapter = jsonRpcMethod.getReturnTypeAdapterFactory().create(gson, TypeToken.get(type));
							if (typeAdapter != null)
								return typeAdapter.fromJsonTree((JsonElement) result);
						}
					}
				}
			}
			return fromJson((JsonElement) result, type);
		}
		return result;
	}

	/**
	 * Convert the json input into the parameters object corresponding to the call
	 * made by method.
	 *
	 * If the method is not known until after parsing, call
	 * {@link #parseParams(Object, String)} on the return value of this call for a
	 * second chance conversion.
	 *
	 * @param in
	 *            json input to read from
	 * @param method
	 *            method name of request
	 * @return correctly typed object if the correct expected type can be
	 *         determined, or a JsonElement representing the parameters
	 */
	protected Object parseParams(JsonReader in, String method) throws IOException, JsonIOException {
		JsonToken next = in.peek();
		if (next == JsonToken.NULL) {
			in.nextNull();
			return null;
		}
		Type[] parameterTypes = getParameterTypes(method);
		if (parameterTypes.length == 1) {
			if (next == JsonToken.BEGIN_ARRAY) {
				/* JsonRPC 2.0: §4.2 Parameter Structures.
				      Wrapping:
				         "some-value" -> ["some-value"]
				         ["some-array"] -> [["some-array"]]
				      By-pass:
				         {type: "some_obj"} -> {type: "some_obj"}
				*/
				// Unwrap by removing the the outermost array.
				in.beginArray();
				var singleParameter = fromJson(in, parameterTypes[0]);
				in.endArray();
				return singleParameter;
			}
			return fromJson(in, parameterTypes[0]);
		}
		if (parameterTypes.length > 1 && next == JsonToken.BEGIN_ARRAY) {
			final var parameters = new ArrayList<>(parameterTypes.length);
			int index = 0;
			in.beginArray();
			while (in.hasNext()) {
				Type parameterType = index < parameterTypes.length ? parameterTypes[index] : null;
				Object parameter = fromJson(in, parameterType);
				parameters.add(parameter);
				index++;
			}
			in.endArray();
			while (index < parameterTypes.length) {
				parameters.add(null);
				index++;
			}
			return parameters;
		}
		JsonElement rawParams = JsonParser.parseReader(in);
		if (method != null && parameterTypes.length == 0 && (
				rawParams.isJsonArray() && rawParams.getAsJsonArray().size() == 0
				|| rawParams.isJsonObject() && rawParams.getAsJsonObject().size() == 0)) {
			return null;
		}
		return rawParams;
	}

	/**
	 * Convert the JsonElement into the parameters object corresponding to the call made
	 * by method. If the result is already converted, does nothing.
	 *
	 * @param params
	 *            json element to read from
	 * @param method
	 *            method name of request
	 * @return correctly typed object if the correct expected type can be
	 *         determined, or params unmodified if no conversion can be done.
	 */
	protected Object parseParams(Object params, String method) {
		if (isNull(params)) {
			return null;
		}
		if (!(params instanceof JsonElement)) {
			return params;
		}
		final var rawParams = (JsonElement) params;
		Type[] parameterTypes = getParameterTypes(method);
		if (parameterTypes.length == 1) {
			return fromJson(rawParams, parameterTypes[0]);
		}
		if (parameterTypes.length > 1 && rawParams instanceof JsonArray) {
			JsonArray array = (JsonArray) rawParams;
			final var parameters = new ArrayList<>(Math.max(array.size(), parameterTypes.length));
			int index = 0;
			Iterator<JsonElement> iterator = array.iterator();
			while (iterator.hasNext()) {
				Type parameterType = index < parameterTypes.length ? parameterTypes[index] : null;
				Object parameter = fromJson(iterator.next(), parameterType);
				parameters.add(parameter);
				index++;
			}
			while (index < parameterTypes.length) {
				parameters.add(null);
				index++;
			}
			return parameters;
		}
		if (method != null && parameterTypes.length == 0 && (
				rawParams.isJsonArray() && rawParams.getAsJsonArray().size() == 0
				|| rawParams.isJsonObject() && rawParams.getAsJsonObject().size() == 0)) {
			return null;
		}
		return rawParams;
	}

	protected Object fromJson(JsonReader in, Type type) throws JsonIOException {
		if (isNullOrVoidType(type)) {
			return JsonParser.parseReader(in);
		}
		return gson.fromJson(in, type);
	}

	protected Object fromJson(JsonElement element, Type type) {
		if (isNull(element)) {
			return null;
		}
		if (isNullOrVoidType(type)) {
			return null;
		}
		Object value = gson.fromJson(element, type);
		if (isNull(value)) {
			return null;
		}
		return value;
	}

	protected boolean isNull(Object value) {
		return value == null || value instanceof JsonNull;
	}

	protected boolean isNullOrVoidType(Type type) {
		return type == null || Void.class == type;
	}

	protected Type[] getParameterTypes(String method) {
		if (method != null) {
			JsonRpcMethod jsonRpcMethod = handler.getJsonRpcMethod(method);
			if (jsonRpcMethod != null)
				return jsonRpcMethod.getParameterTypes();
		}
		return EMPTY_TYPE_ARRAY;
	}

	protected Message createMessage(String jsonrpc, Either<String, Number> id, String method, Object params,
			Object responseResult, ResponseError responseError) throws JsonParseException {
		if (id != null && method != null) {
			final var message = new RequestMessage();
			message.setJsonHandler(handler);
			message.setJsonrpc(jsonrpc);
			message.setRawId(id);
			message.setMethod(method);
			message.setParams(params);
			return message;
		} else if (id != null) {
			final var message = new ResponseMessage();
			message.setJsonHandler(handler);
			message.setJsonrpc(jsonrpc);
			message.setRawId(id);
			if (responseError != null)
				message.setError(responseError);
			else
				message.setResult(responseResult);
			return message;
		} else if (method != null) {
			final var message = new NotificationMessage();
			message.setJsonHandler(handler);
			message.setJsonrpc(jsonrpc);
			message.setMethod(method);
			message.setParams(params);
			return message;
		} else {
			throw new JsonParseException("Unable to identify the input message.");
		}
	}

	@Override
	public void write(JsonWriter out, Message message) throws IOException {
		out.beginObject();
		out.name("jsonrpc");
		out.value(message.getJsonrpc() == null ? MessageConstants.JSONRPC_VERSION : message.getJsonrpc());

		if (message instanceof RequestMessage) {
			final var requestMessage = (RequestMessage) message;
			out.name("id");
			writeId(out, requestMessage.getRawId());
			out.name("method");
			out.value(requestMessage.getMethod());
			out.name("params");
			Object params = requestMessage.getParams();
			if (params == null)
				writeNullValue(out);
			else
				handleParameter(out, params, requestMessage.getMethod());
		} else if (message instanceof ResponseMessage) {
			final var responseMessage = (ResponseMessage) message;
			out.name("id");
			writeId(out, responseMessage.getRawId());
			if (responseMessage.getError() != null) {
				out.name("error");
				gson.toJson(responseMessage.getError(), ResponseError.class, out);
			} else {
				out.name("result");
				Object result = responseMessage.getResult();
				if (result == null)
					writeNullValue(out);
				else
					gson.toJson(result, result.getClass(), out);
			}
		} else if (message instanceof NotificationMessage) {
			final var notificationMessage = (NotificationMessage) message;
			out.name("method");
			out.value(notificationMessage.getMethod());
			out.name("params");
			Object params = notificationMessage.getParams();
			if (params == null)
				writeNullValue(out);
			else
				handleParameter(out, params, notificationMessage.getMethod());
		}

		out.endObject();
	}

	protected void handleParameter(JsonWriter out, Object params, String method) {
		boolean isSingleArray = getParameterTypes(method).length == 1 && Collection.class.isInstance(params)
				|| params.getClass().isArray();
		boolean needsWrap = isSingleArray || params instanceof String || isWrapperType(params.getClass())
				|| isPrimitive(getClass());
		if (needsWrap) {
			gson.toJson(List.of(params), List.class, out);
		} else {
			gson.toJson(params, params.getClass(), out);
		}
	}

	protected void writeId(JsonWriter out, Either<String, Number> id) throws IOException {
		if (id == null)
			writeNullValue(out);
		else if (id.isLeft())
			out.value(id.getLeft());
		else if (id.isRight())
			out.value(id.getRight());
	}

	/**
	 * Use this method to write a {@code null} value even if the JSON writer is set to not serialize {@code null}.
	 */
	protected void writeNullValue(JsonWriter out) throws IOException {
		boolean previousSerializeNulls = out.getSerializeNulls();
		out.setSerializeNulls(true);
		out.nullValue();
		out.setSerializeNulls(previousSerializeNulls);
	}

	/**
	 * Returns true if this type is a primitive.
	 */
	private static boolean isPrimitive(Type type) {
		return type instanceof Class<?> && ((Class<?>) type).isPrimitive();
	}

	/**
	 * Returns {@code true} if {@code type} is one of the nine primitive-wrapper
	 * types, such as {@link Integer}.
	 *
	 * @see Class#isPrimitive
	 */
	private static boolean isWrapperType(Type type) {
		return type == Integer.class || type == Float.class || type == Byte.class || type == Double.class
				|| type == Long.class || type == Character.class || type == Boolean.class || type == Short.class
				|| type == Void.class;
	}
}
